#!/usr/bin/env python

# pytopo: show topographic maps using data files from Topo! CD
# to generate a map at the specified coordinates.
# Home page: http://shallowsky.com/software/topo/

# Copyright 2005 - 2011 by Akkana Peck, akkana@shallowsky.com
# Please feel free to use, distribute or modify this program
# under the terms of the GPL v2 or, at your option, a later GPL version.
# I'd appreciate hearing about it if you make any changes.

# We will do all calculations in decimal degrees,
# but take inputs in deg.decimal_minutes.

"""
1.1 Release notes:

This is mostly a reliability/stability release. Fixed a lot of little
bugs associated with some of the new features: bulk downloading works
better (though still needs more work), saved sites and loaded track
logs work much better, no error if pin.png isn't in the right place, etc.

1.0 Release notes:

PyTopo now supports dragging the map! Wahoo!

0.9 Release notes:

NEW FORMAT

OSMMapCollection handles collections of tiles (maplets) downloaded
from the openstreetmap project

Finding zero-length files:
 find Maps/opencyclemap/13 -size 0
 cd Maps/opencyclemap/13/1429
 #wget http://a.andy.sandbox.cloudmade.com/tiles/cycle/13/1429/3230.png
 wget http://b.tile.opencyclemap.org/cycle/13/1473/3236.png

Downloading your own file collection (faster than doing it interactively):
cd Maps/opencyclemap
  foreach level (*)
    cd Maps/opencyclemap/$level
    foreach dir (*)
      cd Maps/opencyclemap/$level/$dir
        foreach fil (`find . -size 0`)
          echo $dir/$fil
          rm $fil
          #wget http://a.andy.sandbox.cloudmade.com/tiles/cycle/$level/$dir/$fil
          wget http://b.tile.opencyclemap.org/cycle/$level/$dir/$fil
        end
    end
  end
end

TODO: allow configuration of colors, line thickness etc.
"""

VersionString = "PyTopo Version 1.0 by Akkana Peck"

import sys, os, glob, math, gtk, gobject, gc
settings = gtk.settings_get_default()
settings.set_property("gtk-touchscreen-mode", True)
HaveDOM = True
try :
    import xml.dom.minidom
except ImportError:
    HaveDOM = False

import types, urllib, re

#import pdb

#import traceback
#import cProfile

#########################################################
#
# Global Variables that you can override in your .pytopo
# (which may live in $HOME or in $home/.config/pytopo):
#

# Map collections you have, with format information.
# You must define at least one collection in .pytopo.
# It should be a list of instances of MapCollection subtypes.
# Example:
#Collections = [
#    Topo1MapCollection( "mojave", "/home/name/Maps/emj_data", 7.5, 269, 328 ),
#    GenericMapCollection( "pa-geo", "/home/name/Maps/pa-geo-300",
#                          "pa-geo-300-", ".jpg",
#                          -122.497, 37.498, 300, 400, 10746, 13124,
#                          2, True, False )
#]
# The currently supported types are:
#
# Topo1MapCollection: data from local-area Topo! packages,
#   the kind that have 7.5 minute and 15 minute varieties included.
#    (self, _name, _location, _series, tile_w, tile_h) :
#
# Topo2MapCollection: data from local-area Topo! packages that
#   have only the 7.5-minute series and use jpg instead of gif.
#    (collection_name, directory_path, file_prefix, tile_w, tile_h) :
#
# GenericMapCollection: a more general reader, for maps you split up
#   yourself or the Topo! national park maps.
#   ( collection_name, directory_path, filename_prefix, filename_suffix,
#     left_longitude, top_latitude, x_scale, y_scale,
#     num_digits, use_dash, latitude_first )
#    Filenames might look like: pa-map-03-17.png
#    where prefix and suffix are pa-map- and .png,
#    left_longitude and top_latitude specify the top left corner of
#    the (0, 0) image in degrees.decimal_minutes,
#    x_scale and y_scale are in pixels per degree,
#    num_digits is the number of digits used to specify grid points,
#    usedash specifies whether to put a dash between grid numbers,
#    and latitude_first indicates that latitude changes more rapidly
#    than longitude (i.e. in pa-map-03-17.png, it's the third map over
#    and the 17th map down).
# GenericMapCollection is subject to change (to add new parameters) as
# different types of map are added and the rules need to be generalized.
#Collections = []

# Named sites you might want to use as starting points.
# Format: [ sitename, longitude, latitude, collection_name ]
# Coordinates are in degrees.decimal_minutes.
# Example:
# KnownSites = [
#     # San Francisco Bay Area
#     [ "saratogagap", 122.0725, 37.155, "sfr" ],
#     [ "lexington", 121.594, 37.12, "sfr" ],
#     # Death Valley
#     [ "zabriskie", 116.475, 36.245, "deathvalley" ],
#     # From the Big Sur map:
#     [ "pinnacles", 121.0865, 36.3247, "bigsur" ],
#     ]
#KnownSites = []

#
# End of variables likely to need customization.
#
#########################################################

#########################################################
#
# Types of map collections we understand.
# If you split your own map into maplets, you may
# want to define your own subclass to handle it:
# see GenericMapCollection for an example.
#
# You can put your own subclasses in ~/.pytopo,
# but please consider contributing them so I can
# integrate them into future PyTopo releases!
#

Debug = False

class MapCollection :

    """A MapCollection is a set of maplet tiles on disk,
combined with knowledge about the geographic coordinates
and scale of those tiles so they can be drawn in a map window.

Child classes implementing MapCollection must define functions
__init__, get_maplet, draw_map, and get_top_left.
Get_top_left() is only for debugging, when you're trying to figure out
map coordinates and need a starting place. Should probably remove it.
"""

    def __init__(self, _name, _location) :
        self.name = _name
        self.location = _location

        # Set some defaults so that we can test pytopo with a null collection:
        self.img_width = 100
        self.img_height = 100
        self.xscale = 100.
        self.yscale = 100.

    def get_maplet(self, longitude, latitude) :
        """Returns pixbuf, x_offset, y_offset:
         - the pixbuf for the maplet image (or null)
         - the offset in pixels into the image for the given coordinates,
           from top left.
        """
        return None, 0, 0

    def draw_map(self, center_lon, center_lat, drawwin) :
        """Draw a map in a window, centered around the specified coordinates.
        drawwin is a DrawWin object."""
        return

    def get_top_left(self) :
        """A way to display some part of a map collection even if we're fuzzy
        on the coordinates -- get the coordinate of the first maplet
        and return as longitude, latitude."""
        return 0, 0

    def zoom(self, amount, latitude=45) :
        """Zoom by the given number of steps (positive to zoom in,
        negative to zoom out). Pass amount=0 to recalculate/redraw.
        Some map collections need to know latitude to determine scale.
        """
        return

    def zoom_to(self, newzoom, latitude=45) :
        """Zoom to a specific zoom level and recalculate scales.
        Some map collections need to know latitude to determine scale.
        """
        return

    def exists(self) :
        """Does the collection have its map files in place?"""
        self.location = os.path.expanduser(self.location)
        return os.access(self.location, os.X_OK)

class TiledMapCollection(MapCollection) :

    """Code common to map collections that have raster tiles of a fixed size.
TiledMapCollection classes must implement
  (pixbuf, x_off, y_off, pathname) = get_maplet(curlon, curlat)
  (pixbuf, newpath) = get_next_maplet(oldpath, dX, dY)
"""

    def __init__(self, _name, _location, _tile_w, _tile_h) :
        MapCollection.__init__(self, _name, _location)
        self.img_width = _tile_w
        self.img_height = _tile_h

        # For collections that support downloading new tiles,
        # keep a list of tiles that still need downloading:
        self.download_tiles = DownloadTileQueue()
        self.download_func = None
        self.download_failures = 0

    def draw_map(self, center_lon, center_lat, mapwin) :
        """Draw maplets at the specified coordinates, to fill the mapwin."""

        # Get the current window size:
        win_width, win_height = mapwin.get_size()
        if (Debug) :
            print "Window is", win_width, "x", win_height

        # Now that we have a latitude, call zoom so we can finally
        # set the x and y scales accurately.
        self.zoom(0, center_lat)

        # Find the coordinate boundaries for the set of maps to draw.
        # This may (indeed, usually will) include maps partly off the screen,
        # so the coordinates will span a greater area than the visible window.
        if (Debug) :
            print "Calculating boundaries: min =", \
                   MapUtils.DecDegToDegMinStr(center_lon), \
                   center_lon, "+/-", win_width, \
                   "/", self.xscale, "/ 2"
        min_lon = center_lon - win_width / self.xscale / 2
        max_lon = center_lon + win_width / self.xscale / 2
        min_lat = center_lat - win_height / self.yscale / 2
        max_lat = center_lat + win_height / self.yscale / 2

        if (Debug) :
            print "Map from", min_lon, MapUtils.DecDegToDegMinStr(min_lon), \
                   MapUtils.DecDegToDegMinStr(min_lat), \
                   "to", MapUtils.DecDegToDegMinStr(max_lon), \
                   MapUtils.DecDegToDegMinStr(max_lat)

        # Start from the upper left: min_lon, max_lat

        #pdb.set_trace()
        curlat = max_lat
        cur_y = 0
        y_maplet_name = None
        initial_x_off = None
        while cur_y < win_height:
            curlon = min_lon
            cur_x = 0
            x_maplet_name = None
            while cur_x < win_width :

                # Reset the expected image size:
                w = self.img_width
                h = self.img_height

                # Is it the first maplet in this row?
                if x_maplet_name == None :

                    # Is it the first maplet in the map --
                    # usually the one in the upper left corner?
                    # Then we need to specify coordinates.
                    if y_maplet_name == None :
                        pixbuf, x_off, y_off, x_maplet_name = \
                            self.get_maplet(curlon, curlat)

                        # Save the x offset: we'll need it for the
                        # beginning of each subsequent row.
                        initial_x_off = x_off

                    # Not upper left corner --
                    # must be the beginning of a new row.
                    # Get the maplet below the beginning of the last row.
                    else :
                        pixbuf, x_maplet_name = \
                            self.get_next_maplet(y_maplet_name, 0, 1)
                        x_off = initial_x_off
                        y_off = 0

                    # Either way, whether or not we got a pixbuf,
                    # if we're at the beginning of a row, save the
                    # beginning-of-row maplet name and the offset:
                    if cur_x == 0 :
                        y_maplet_name = x_maplet_name

                # Continuing an existing row.
                # Get the maplet to the right of the last one.
                else :
                    pixbuf, x_maplet_name = self.get_next_maplet(x_maplet_name,
                                                                 1, 0)
                    x_off = 0

                if Debug :
                    print "    ", x_maplet_name

                x = cur_x
                y = cur_y

                # If the pixbuf wasn't available, the collection may return
                # a URL to be downloaded. Check for that:
                if type(pixbuf) == types.TupleType :
                    # XXX Make sure it's not not already queued for download:
                    self.download_tiles.push(pixbuf[0], pixbuf[1],
                                             x, y, x_off, y_off,
                                             mapwin)
                    pixbuf = None

                w, h = self.draw_one_tile(pixbuf, mapwin, x, y, x_off, y_off)
                # You may ask, why not just do this subtraction before
                # draw_pixbuf so we don't have to subtract w and h twice?
                # Alas, we may not have the real w and h until we've done
                # pixbuf.get_width(), so we'd be subtracting the wrong thing.
                # XXX Not really true any more, since we're assuming fixed
                # XXX tile size. Revisit this!
                cur_x += w
                curlon += float(w) / self.xscale

            if (Debug) :
                print " "
                print "New row: adding y =", h,
                print "Subtracting lat", float(h) / self.yscale

            cur_y += h
            curlat -= float(h) / self.yscale
            #curlat -= float(self.img_height) / self.yscale

        # Free all pixbuf data. Just letting pixbuf go out of scope
        # isn't enough; it's necessary to force garbage collection
        # otherwise Python will let the process grow until it
        # fills all of memory.
        # http://www.daa.com.au/pipermail/pygtk/2003-December/006499.html
        # (At this indentation level, we free after draing the whole map.)
        gc.collect()

        # If we queued any downloads, schedule a function to take care of that:
        if len(self.download_tiles) > 0 and self.download_func == None :
            gobject.timeout_add(300, self.download_more)

    def get_next_maplet_name(self, fullpathname, dX, dY) :
        """Starting from a maplet name, get the one a set distance away."""
        return

    def get_next_maplet(self, fullpathname, dX, dY) :
        """Given a maplet's pathname, get the next or previous one.
        May not work for jumps more than 1 in any direction.
        Returns pixbuf, newpath (either may be None).
        """
        return

    def draw_one_tile(self, pixbuf, mapwin, x, y, x_off, y_off) :
        """Draw a single tile, perhaps after downloading it."""
        if pixbuf != None :
            w = pixbuf.get_width() - x_off
            h = pixbuf.get_height() - y_off
            if (Debug) :
                print "img size:", pixbuf.get_width(), \
                      pixbuf.get_height()

            # If the image won't completely fill the grid space,
            # fill the whole rectangle first with black.
            # Note: this may not guard against images with
            # transparent areas. Don't do that.
            if (pixbuf.get_width() < self.img_width or
                pixbuf.get_height() < self.img_height) :
                mapwin.set_bg_color()
                mapwin.draw_rectangle(1, x, y,
                                      self.img_width, self.img_height)
                if (Debug) :
                    print "Filling in background:", x, y,
                    print self.img_width, self.img_height

            # if (Debug) :
            #     print "Drawing maplet for",
            #     print MapUtils.DecDegToDegMinStr(curlon),
            #     print MapUtils.DecDegToDegMinStr(curlat),
            #     print "at", x, y, "offset", x_off, y_off,
            #     print "size", w, h

            mapwin.draw_pixbuf(pixbuf, x_off, y_off, x, y, w, h)

            # Make sure the pixbuf goes out of scope properly:
            pixbuf = 0
        else :
            # if (Debug) :
            #     print "No maplet for", curlon, curlat,
            #     print "at", x, y, "offset", x_off, y_off
            mapwin.set_bg_color()
            w = self.img_width - x_off
            h = self.img_height - y_off
            mapwin.draw_rectangle(1, x, y, w, h)

        # Useful when testing:
        if (Debug) :
            mapwin.set_grid_color()
            mapwin.draw_rectangle(0, x, y, w, h)
            mapwin.draw_line(x, y, x+w, y+h)
            mapwin.set_bg_color()
        return w, h

    def download_finished(self, path) :
        """Callback when a tile finishes downloading.
           The path argument is either the local file path just downloaded,
           or an exception, e.g. IOError.
        """

        # If we got too many failures -- usually IOError,
        # perhaps we're offline -- path will be None here.
        # In that case, just give up on downloading.
        if path == None :
            self.download_failures += 1
            if self.download_failures > 5 :
                print "Download failed; giving up"
                self.download_func = None
                # Clear self.download_tiles, so that if the net returns
                # we'll start on new stuff, not old stuff.
                # Not clear if this is the right thing to do or not.
                self.download_tiles = DownloadTileQueue()
                self.download_failures = 0
                return

        # Otherwise, we got a path for a successful tile download.
        # Reset the failure counter:
        #self.download_failures += 1

        # Draw it on the map:
        url, path, x, y, x_off, y_off, mapwin = self.download_tiles.pop()
        try :
            pixbuf = gtk.gdk.pixbuf_new_from_file(path)
            self.draw_one_tile(pixbuf, mapwin, x, y, x_off, y_off)
        except Exception, e:
            print "Couldn't draw tile:", e
            self.download_failures += 1

        # Redraw any trackpoints, since they might have been overwritten:
        mapwin.draw_trackpoints()

        # It's okay to start a new download now:
        self.download_func = None

        # Anything more to download?
        if len(self.download_tiles) > 0 :
            self.download_more()

    def download_more(self) :
        """Idle/timeout proc to download any pending tiles.
           Should always return False so it won't get rescheduled.
           Eventually this should download in a separate thread.
        """

        # If we already have a download going, don't start another one
        # (eventually we'll want to run several in parallel).
        if self.download_func != None :
            if Debug :
                print "There's already a download going; not downloading more"
            return False

        # If there are no more tiles to download, we're done:
        if len(self.download_tiles) == 0 :
            self.download_func = None
            return False

        url, path, x, y, x_off, y_off, mapwin = self.download_tiles.peek()
        # Don't actually pop() it until it has downloaded.
        #urllib.urlretrieve(url, path)
        self.download_func = start_job(download_job(url, path,
                                                    self.download_finished))
        if Debug :
            print "Started download %s to %s" % (url, path)

        return False

class OSMMapCollection(TiledMapCollection) :

    """
    A collection of tiles downloaded from the OpenStreetMap project
    or one of its renderers, using the OSM naming scheme.
    See also http://tfischernet.wordpress.com/2009/05/04/drawing-gps-traces-on-map-tiles-from-openstreetmap/
    """

    def __init__(self, _name, _location, _ext,
                 _img_width, _img_height, _init_zoom,
                 _download_url=None) :
        """arguments:
        name         -- user-visible name of the collection
        location     -- directory on disk where the maps reside
        ext          -- filename extension including the dot, e.g. .jpg
        img_width    -- width of each maplet
        img_height   -- height of each maplet
        init_zoom    -- default initial zoom level
        download_url -- try to download missing maplets from here
        """
        TiledMapCollection.__init__(self, _name, _location,
                                    _img_width, _img_height)
        self.ext = _ext
        self.img_width = _img_width
        self.img_height = _img_height
        self.zoomlevel = _init_zoom
        self.powzoom = 2.0 ** self.zoomlevel   # to avoid re-re-calculating
        self.download_url = _download_url

        self.location = os.path.expanduser(self.location)

        # Handle ~ format for location

        # If we're download-capable, we'd better have a directory
        # to download to, so make it if it's not there already:
        if self.download_url and not os.access(self.location, os.W_OK) :
            # XXX wrap in a try, show user-visible error dialog!
            os.makedirs(self.location)

        # Call zoom so we set all scales appropriately:
        self.zoom(0)

    # Utilities for mapping tiles to/from degrees.
    # From http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
    def deg2num(self, lat_deg, lon_deg, zoom=None):
        """Map coordinates to tile numbers and offsets"""
        if zoom :
            powzoom = 2.0 ** zoom
        else :
            powzoom = self.powzoom
        lat_rad = math.radians(lat_deg)
        xtilef = (lon_deg + 180.0) / 360.0 * powzoom
        ytilef = ((1.0 - math.log(math.tan(lat_rad) +
                                  (1 / math.cos(lat_rad))) / math.pi)
                  / 2.0 * powzoom)
        xtile = int(xtilef)
        ytile = int(ytilef)

        tilesize = 256
        x_off = int((xtilef - xtile) * tilesize)
        y_off = int((ytilef - ytile) * tilesize)

        return(xtile, ytile, x_off, y_off)

    def num2deg(self, xtile, ytile):
        """Map file numbers to coordinates"""
        lon_deg = xtile / self.powzoom * 360.0 - 180.0
        lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / self.powzoom)))
        lat_deg = math.degrees(lat_rad)
        return(lat_deg, lon_deg)

    def zoom_to(self, newzoom, latitude=45) :
        """Zoom to a specific zoom level, updating scales accordingly.
        Pass latitude for map collections (e.g. OSM) that cover
        large areas so scale will tend to vary with latitude.
        """

        if self.zoomlevel != newzoom :
            self.zoomlevel = newzoom
            self.powzoom = 2.0 ** self.zoomlevel

        # Get scale, in pixels / degree.
        # (2 ** zoomlevel) tiles covers the whole world.
        self.xscale = self.powzoom * 180./256.

        # But because of the Mercator projection,
        # yscale has to be adjusted for latitude.
        (xtile, ytile, x_off, y_off) = self.deg2num(latitude, 180)
        (lat1, lon1) = self.num2deg(xtile, ytile)
        (lat2, lon2) = self.num2deg(xtile+1, ytile-1)
        self.xscale = 256. / (lon2 - lon1)
        self.yscale = 256. / (lat2 - lat1)
        if Debug :
            print "Zoom to %d: Calculated scales: %f, %f" \
                % (self.zoomlevel, self.xscale, self.yscale)
        return

    def zoom(self, amount, latitude=45) :
        """Zoom in or out by the specified amount,
        updating the scales appropriately.
        Call zoom(0) to update x/y scales without changing zoom level.
        Pass latitude for map collections (e.g. OSM) that cover
        large areas so scale will tend to vary with latitude.
        """
        self.zoom_to(self.zoomlevel + amount, latitude)

    def get_maplet(self, longitude, latitude) :
        """Fetch or queue download for the maplet containing the
        specified coordinates.
        Input coordinates are in decimal degrees.
        Returns pixbuf, x_offset, y_offset, filename
        where offsets are pixels from top left of the specified coords
        and pixbuf or (less often) filename may be None.
        """

        (xtile, ytile, x_off, y_off) = self.deg2num(latitude, longitude)

        filename = os.path.join(self.location, str(self.zoomlevel),
                                str(xtile), str(ytile)) + self.ext
        pixbuf = self.fetch_or_download_maplet(filename)
        return pixbuf, x_off, y_off, filename

    # maplet size is 256. Files per dir:
    # at zoomlevel 12, 28
    # at zoomlevel 13, 53
    # at zoomlevel 14, 107
    def get_next_maplet_name(self, fullpathname, dX, dY) :
        """Starting from a maplet name, get the one a set distance away."""
        fulldir, filename = os.path.split(fullpathname)
        ystr, ext = os.path.splitext(filename)
        zoomdir, xstr = os.path.split(fulldir)
        xstr = str(int(xstr) + dX)
        ystr = str(int(ystr) + dY)

        return os.path.join(zoomdir, xstr, ystr + ext)

    def get_next_maplet(self, fullpathname, dX, dY) :
        """Given a maplet's pathname, get the next or previous one.
        May not work for jumps more than 1 in any direction.
        Returns pixbuf, newpath (either may be None).
        """
        newpath = self.get_next_maplet_name(fullpathname, dX, dY)
        if newpath == None :
            return None, newpath

        pixbuf = self.fetch_or_download_maplet(newpath)
        return pixbuf, newpath

    def url_from_path(self, path, zoomlevel=None) :
        """URL we need to get the given tile file"""
        if not zoomlevel :
            zoomlevel = self.zoomlevel
        xdir, basename = os.path.split(path)
        xdir = os.path.basename(xdir)
        return self.download_url + '/' + str(zoomlevel) + '/' \
            + xdir + '/' + basename

    def fetch_or_download_maplet(self, path) :
        """Return a pixbuf if the file is on disk, else (url, path)"""
        if not os.access(path, os.R_OK) :
            if not self.download_url :
                if Debug :
                    print "Downloads not enabled; skipping", path
                return None

            # path is a full path on the local filesystem, OS independent.
            # We need to turn it into a url (Unix path) with slashes.
            thedir = os.path.dirname(path)
            if not os.access(thedir, os.W_OK) :
                os.makedirs(thedir)
            return (self.url_from_path(path), path)

        try :
            pixbuf = gtk.gdk.pixbuf_new_from_file(path)
        except gobject.GError :
            return None

        return pixbuf

    def coords_to_filename(self, longitude, latitude) :
        """Given coordinates in decimal degrees, map to the closest filename"""
        return None

    def get_top_left(self) :
        """Get the coordinates of the top left corner of the map."""
        return None, None

class GenericMapCollection(TiledMapCollection) :

    """
    A GenericMapCollection is tiled, like the Topo collections,
    but uses a less specific naming scheme:
    prefix-nn-mm.ext, with or without the dashes.
    """

    def __init__(self, _name, _location, _prefix, _ext,
                 _left_long, _top_lat,
                 _img_width, _img_height, _xscale, _yscale,
                 _numdigits, _usedash, _latfirst) :
        """arguments:
        name       -- user-visible name of the collection
        location   -- directory on disk where the maps reside
        prefix     -- initial part of each maplet filename
        ext        -- filename extension including the dot, e.g. .jpg
        left_long  -- longitude of the left edge
        top_lat    -- latitude of the top edge
        img_width  -- width of each maplet in pixels
        img_height -- height of each maplet in pixels
        xscale     -- pixels per degree longitude
        yscale     -- pixels per degree latitude
        numdigits  -- number of digits in x and y file specifiers
        usedash    -- Boolean, use a dash between x and y in filenames?
        latfirst   -- Boolean, is latitude the first of the two numbers?

        """
        TiledMapCollection.__init__(self, _name, _location,
                                    _img_width, _img_height, )
        self.prefix = _prefix
        self.numdigits = _numdigits
        self.usedash = _usedash
        self.ext = _ext
        self.latfirst = _latfirst
        self.img_width = _img_width
        self.img_height = _img_height
        self.left_longitude = _left_long    # Left of 00-00 image
        self.top_latitude = _top_lat        # Top of 00-00 image
        self.xscale = float(_xscale)        # Pixels per degree
        self.yscale = float(_yscale)        # Pixels per degree

    def get_maplet(self, longitude, latitude) :
        """Get the maplet containing the specified coordinates.
        Returns pixbuf, x_offset, y_offset, filename
        where offsets are pixels from top left of the specified coords
        and pixbuf or (less often) filename may be None.
        """
        filename = self.coords_to_filename(longitude, latitude)
        if (Debug) :
            print "Generic get_maplet", longitude, latitude, "->", filename
        if filename == None or not os.access(filename, os.R_OK) :
            #print "Can't open", filename, "for", longitude, latitude
            return None, 0, 0, filename
        #print "Opened", filename, "for", longitude, latitude
        pixbuf = gtk.gdk.pixbuf_new_from_file(filename)

        # Offsets aren't implemented yet:
        x_off = 0
        y_off = 0

        return pixbuf, x_off, y_off, filename

    def get_next_maplet(self, fullpathname, dX, dY) :
        """Given a maplet's pathname, get the next or previous one.
        Does not currently work for jumps more than 1 in any direction.
        Returns pixbuf, newpath (either may be None).
        """
        pathname, filename = os.path.split(fullpathname)
        if (Debug) :
            print "Generic get_next_maplet", filename, dX, dY
        name, ext = os.path.splitext(filename)
        #traceback.print_stack()
        mapb = int(name[-self.numdigits:])
        if self.usedash :
            mapa = int(name[-self.numdigits*2 - 1 : -self.numdigits-1])
        else :
            mapa = int(name[-self.numdigits*2 : -self.numdigits])
        if self.latfirst :
            newa = MapUtils.ohstring(mapa + dX, self.numdigits)
            newb = MapUtils.ohstring(mapb + dY, self.numdigits)
        else :
            newa = MapUtils.ohstring(mapa + dY, self.numdigits)
            newb = MapUtils.ohstring(mapb + dX, self.numdigits)
        if self.usedash :
            newname = self.prefix + newa + "-" + newb
        else :
            newname = self.prefix + newa + newb
        newpath = os.path.join(self.location, newname + ext)
        if filename == None or not os.access(filename, os.R_OK) :
            return None, newpath
        pixbuf = gtk.gdk.pixbuf_new_from_file(newpath)
        return pixbuf, newpath

    def coords_to_filename(self, longitude, latitude) :
        """Given coordinates in decimal degrees, map to the closest filename"""
        if self.left_longitude > longitude or self.top_latitude < latitude :
            return None
        x_grid = MapUtils.intTrunc((longitude - self.left_longitude) *
                                   self.xscale / self.img_width)
        y_grid = MapUtils.intTrunc((self.top_latitude - latitude) *
                                   self.yscale / self.img_height)
        if not self.latfirst :
            temp = x_grid
            x_grid = y_grid
            y_grid = temp
        retstr = os.path.join(self.location,
                              self.prefix + MapUtils.ohstring(x_grid,
                                                              self.numdigits))
        if self.usedash:
            retstr = retstr + "-"
        retstr = retstr + MapUtils.ohstring(y_grid, self.numdigits) + self.ext
        return retstr

    def get_top_left(self) :
        """Get the coordinates of the top left corner of the map."""
        return self.left_longitude, self.top_latitude

class TopoMapCollection(TiledMapCollection) :

    """TiledMapCollections using the Topo! map datasets.
    Filenames are named according to a fairly strict convention.
    Some variants can toggle between more than one scale (series).
    """

    def __init__(self, _name, _location, _series, _tile_w, _tile_h,
                 _ser7prefix="012t", _ser15prefix="024t", _img_ext=".gif") :
        """arguments:
        name        -- user-visible name of the collection
        location    -- directory on disk where the maps reside
        series      -- initial series to use, 7.5 or 15 minutes of arc.
        tile_w      -- width of each maplet in pixels
        tile_h      -- height of each maplet in pixels
        img_ext     -- filename extension including the dot, e.g. .jpg
        ser7prefix  -- prefix for tile files implementing the 7.5-min series
        ser15prefix -- prefix for tile files implementing the 15-min series
        """

        TiledMapCollection.__init__(self, _name, _location, _tile_w, _tile_h)
        self.set_series(_series)
        self.ser7prefix = _ser7prefix
        self.ser15prefix = _ser15prefix
        self.img_ext = _img_ext

        # _correction because Topo1 maps aren't in WGS 84.
        # Right now these numbers are EMPIRICAL and inaccurate.
        # Need to do them right!
        # http://www.ngs.noaa.gov/cgi-bin/nadcon.prl says the correction
        # in the Mojave area from NAD27 to NAD84 (nobody converts to
        # WGS84, alas) should be -0.05463', 2.99014' (-1.684m, 75.554m)
        self.lon_correction = 0 # 0.032778 / 1000
        self.lat_correction = 0 # -1.794084 / 1000

    def set_series(self, _series) :
        """Set the series to either 7.5 or 15 minutes."""

        #traceback.print_stack()
        self.series = _series
        self.xscale = self.img_width * 600.0 / self.series
        self.yscale = self.img_height * 600.0 / self.series
        if (Debug) :
            print "set series to", self.series
        # 600 is minutes/degree * maplets/minute

        # The fraction of a degree that each maplet spans:
        self.frac = float(self.img_width) / self.xscale
        if (Debug) :
            if self.frac != float(self.img_height) / self.yscale :
                print "x and y fractions not equal!",
                print self.frac, float(self.img_height) / self.yscale

    def get_maplet(self, longitude, latitude) :
        """Get the maplet containing the specified coordinates.
        Returns pixbuf, x_offset, y_offset, filename
        where offsets are pixels from top left of the specified coords
        and pixbuf or (less often) filename may be None.
        """

        filename = self.coords_to_filename(longitude - self.lon_correction,
                                         latitude - self.lat_correction)
        if (Debug) :
            print "T1MC get_maplet(", MapUtils.DecDegToDegMinStr(longitude),
            print  ",", MapUtils.DecDegToDegMinStr(latitude), "):", filename

        # Calculate offsets.
        # Maplets are self.series minutes wide and tall,
        # so any offset from that is an offset into the maplet:
        # the number of pixels in X and Y that have to be added
        # to get from the maplet's upper left corner to the
        # indicated coordinates.
        # But then we have to correct to get to WGS84 coordinates.
        # XXX the WGS84 part doesn't work right yet.

        # longitude increases rightward:
        x_off = int((longitude - MapUtils.TruncateToFrac(longitude, self.frac)
                     - self.lon_correction) * self.xscale)
        if (Debug) :
            print "truncated", MapUtils.DecDegToDegMinStr(longitude), "to",
            print MapUtils.DecDegToDegMinStr(MapUtils.TruncateToFrac(longitude,
                                                                     self.frac))

        # Latitude decreases downward:
        y_off = int((MapUtils.TruncateToFrac(latitude, self.frac) +
                     self.frac - latitude - self.lat_correction) * self.yscale)

        if (Debug) :
            print "truncated", MapUtils.DecDegToDegMinStr(latitude), "to",
            print MapUtils.DecDegToDegMinStr(MapUtils.TruncateToFrac(latitude,
                                                                     self.frac))
            print "y_off is", y_off

        if not os.access(filename, os.R_OK) :
            return None, x_off, y_off, filename
        pixbuf = gtk.gdk.pixbuf_new_from_file(filename)

        return pixbuf, x_off, y_off, filename

    def get_next_maplet(self, fullpathname, dX, dY) :
        """Given a maplet's pathname, get the next or previous one.
        Does not currently work for jumps more than 1 in any direction.
        Returns pixbuf, newpath (either may be None).
        """

        if (Debug) :
            print "get_next_maplet:", fullpathname, dX, dY
        pathname, filename = os.path.split(fullpathname)
        collecdir, mapdir = os.path.split(pathname)
        maplat = mapdir[1:3]
        maplon = mapdir[3:6]
        name, ext = os.path.splitext(filename)
        xdir = int(mapdir[-1])
        ydir = ord(mapdir[-2]) - ord('a')     # ydir is a letter a-h
        if self.series == 7.5 :
            serstr = self.ser7prefix
            grid = 10
        else :
            serstr = self.ser15prefix
            grid = 5

        x = int(name[-4:-2]) + dX
        y = int(name[-2:]) + dY

        if x < 1 :
            x = grid
            xdir = xdir + 1
            if xdir > 8 :
                xdir = 1
                if Debug :
                    print mapdir, name, ": wrapping mapdir coordinates -x",
                    print maplon
                maplon = str(int(maplon) + 1)
        if x > grid :
            x = 1
            xdir = xdir - 1
            if xdir < 1 :
                xdir = 8
                if Debug :
                    print mapdir, name, ": wrapping mapdir coordinates +x",
                    print maplon
                maplon = str(int(maplon) - 1)

        if y > grid :
            y = 1
            ydir = ydir - 1
            if ydir < 0 :
                ydir = 7
                if Debug :
                    print mapdir, name, ": wrapping mapdir coordinates +y",
                    print maplat
                maplat = str(int(maplat) - 1)

        if y < 1 :
            y = grid
            ydir = ydir + 1
            if ydir > 7 :
                ydir = 0
                if Debug :
                    print mapdir, name, ": wrapping mapdir coordinates -y",
                    print maplat
                maplat = str(int(maplat) + 1)

        # We're ready to piece the filename back together!
        newpath = os.path.join(collecdir,
                               "q" + MapUtils.ohstring(maplat, 2) \
                                   + MapUtils.ohstring(maplon, 3) \
                                   + chr(ydir + ord('a')) + str(xdir),
                               serstr + MapUtils.ohstring(x, 2) \
                                   + MapUtils.ohstring(y, 2) + ext)
        if not os.access(newpath, os.R_OK) :
            if Debug :
                print "get_next_maplet(", fullpathname, dX, dY, ")"
                print "  Can't open", newpath
            return None, newpath

        pixbuf = gtk.gdk.pixbuf_new_from_file(newpath)
        return pixbuf, newpath

    #
    # Quirk: Topo1 collections are numbered with WEST longitude --
    # i.e. longitude is written as positive but it's actually negative.
    #
    # Second quirk: Topo1 collections aren't in the WGS 84 coordinate
    # system used by GPS receivers, and need to be translated.
    # http://en.wikipedia.org/wiki/Geographic_coordinate_system
    # http://en.wikipedia.org/wiki/Geodetic_system
    #
    def coords_to_filename(self, longitude, latitude) :
        """Given a pair of coordinates in deg.mmss, map to the
        containing filename, e.g. q37122c2/012t0501.gif.
        """

        latDeg = MapUtils.intTrunc(latitude)
        longDeg = MapUtils.intTrunc(-longitude)
        latMin = (latitude - latDeg ) * 60.
        longMin = (-longitude - longDeg) * 60.

        # The 7.5 here is because of the 7.5 in the directory names above
        # (we're getting the offset of this image from the origin of
        # the 7.5-series map covered by the directory),
        # not the map series we're actually plotting now.
        longMinOrd = MapUtils.intTrunc(longMin / 7.5)
        latMinOrd = MapUtils.intTrunc(latMin / 7.5)

        dirname = "q" + MapUtils.ohstring(latDeg, 2) \
            + MapUtils.ohstring(longDeg, 3) \
            + chr(ord('a') + latMinOrd) + str(longMinOrd+1)

        # Find the difference between our desired coordinates
        # and the origin of the map this directory represents.
        # The 7.5 here is because of the 7.5 in the directory names above.
        latMinDiff = latMin - (latMinOrd * 7.5)
        longMinDiff = longMin - (longMinOrd * 7.5)

        latOffset = MapUtils.intTrunc(latMinDiff * 10 / self.series)
        longOffset = MapUtils.intTrunc(longMinDiff * 10 / self.series)

        # Now calculate the current filename.
        # Note that series is either 7.5 or 15
        if (self.series > 13) :
            fileprefix = "024t"
            numcharts = 5
        else :
            fileprefix = "012t"
            numcharts = 10
        filename = fileprefix + MapUtils.ohstring(numcharts-longOffset, 2) + \
                   MapUtils.ohstring(numcharts-latOffset, 2) + self.img_ext

        return self.location + "/" + dirname + "/" + filename

    def dir_to_latlong(self, qdir) :
        """Given a directory, figure out the corresponding coords."""
        letter = ord(qdir[6]) - ord('a')
        digit = int(qdir[7]) - 1
        thislon = -int(qdir[3:6]) + (digit * 7.5 * 1.5 / 60)
        #thislon += self.lon_correction
        thislat = int(qdir[1:3]) + (letter * 7.5 * 1.5 / 60)
        #thislat += self.lat_correction
        return thislon, thislat

    def get_top_left(self) :
        """Get the coordinates of the top left corner of the map."""
        minlong = 181
        maxlat = -91
        topleftdir = None

        mapdirs = os.listdir(self.location)
        # mapdirs.sort()
        for mapdir in mapdirs :
            if mapdir[0] == 'q' :
                # Now first_mapdir is some name like "qAAABBcD" ... decode it.
                thislong, thislat = self.dir_to_latlong(mapdir)
                #if thislong < minlong and thislat > maxlat :
                if thislong < minlong :
                    minlong = thislong
                    if thislat > maxlat :
                        maxlat = thislat
                        topleftdir = mapdir
        if maxlat < -90 or minlong > 180 :
            return 0, 0    # shouldn't happen

        # Now we have the top left directory. Still need the top left file:
        files = os.listdir(os.path.join(self.location, topleftdir))

        return minlong, maxlat

# End of TopoMapCollection class

class Topo1MapCollection(TopoMapCollection) :

    """
Topo1MapCollection: data from local-area Topo! packages,
  the kind that have 7.5 minute and 15 minute varieties included.
   (self, _name, _location, _series, tile_w, tile_h) :
    """

    def __init__(self, _name, _location, _series, _tile_w, _tile_h) :
        TopoMapCollection.__init__(self, _name, _location, _series,
                                   _tile_w, _tile_h,
                                   _ser7prefix="012t", _ser15prefix="024t",
                                   _img_ext=".gif")

    def zoom(self, amount, latitude=45) :
        if self.series == 7.5 and amount < 0 :
            self.set_series(15)
        elif self.series == 15 and amount > 0 :
            self.set_series(7.5)

# A Topo2MapCollection is just a Topo1MapCollection that has only
# 7.5-series and has a different file prefix.
# On North Palisade 7.5 (q37118a5) we get 410x256 pixel images.
class Topo2MapCollection(TopoMapCollection) :

    """
Topo2MapCollection: data from local-area Topo! packages that
  have only the 7.5-minute series and use jpg instead of gif.
   (collection_name, directory_path, file_prefix, tile_w, tile_h) :
    """

    def __init__(self, _name, _location, _prefix, _tile_w, _tile_h) :
        TopoMapCollection.__init__(self, _name, _location, 7.5,
                                   _tile_w, _tile_h,
                                   _ser7prefix=_prefix, _ser15prefix=None,
                                   _img_ext=".jpg")

class TrackPoints :

    """Parsing and handling of GPS track files.
    Currently only GPX format is supported.
    """

    def __init__(self) :
        self.points = []
        self.waypoints = []
        self.minlon = 361
        self.maxlon = -361
        self.minlat = 91
        self.maxlat = -91

    def handleTrackPoint(self, point, waypoint=False) :
        #time = getVal(point, "time")
        lat = float(point.getAttribute("lat"))
        lon = float(point.getAttribute("lon"))
        #ele = float(getVal(point, "ele"))
        #ele = round(ele * 3.2808399, 2)      # convert from meters to feet

        if lon < self.minlon :
            self.minlon = lon
        elif lon > self.maxlon :
            self.maxlon = lon
        if lat < self.minlat :
            self.minlat = lat
        elif lat > self.maxlat :
            self.maxlat = lat

        if (waypoint) :
            name = "WP"
            n = point.getElementsByTagName("name")
            if len(n) > 0 :
                n = n[0].childNodes
                if len(n) >= 1 and n[0].nodeType == n[0].TEXT_NODE :
                    name = n[0].data

            self.waypoints.append([lon, lat, name])
        else :
            self.points.append([lon, lat])

    def get_bounds(self) :
        return self.minlon, self.minlat, self.maxlon, self.maxlat

    def readTrackFile(self, filename) :
        """Read a track file. Throw IOError if the file doesn't exist."""
        global Debug

        if not os.path.exists(filename) :
            raise IOError("Can't open track file %s" % filename)

        if (Debug) :
            print "Using track file", filename

        if not HaveDOM :
            print "WARNING: Can't read track file: need module xml.dom.minidom"
            return
        if (Debug) :
            print "Reading track file", filename
        dom = xml.dom.minidom.parse(filename)

        # Handle track(s)
        trkpts = dom.getElementsByTagName("trkpt")
        for i in range (0, len(trkpts), 1) :
            self.handleTrackPoint(trkpts[i], False)

        # Handle waypoints
        waypts = dom.getElementsByTagName("wpt")
        for i in range (0, len(waypts), 1) :
            self.handleTrackPoint(waypts[i], True)

        # GPX also allows for routing, rtept, but I don't think we need those.

class MapUtils :

    """MapUtils really just exists to contain a bunch of utility
    functions useful for mapping classes.
    """

    @staticmethod
    def DegMinToDecDeg(coord) :
        """Convert degrees.minutes to decimal degrees"""
        deg = MapUtils.intTrunc(coord)
        dec = (coord - deg) / .6
        return deg + dec

    @staticmethod
    def DecDegToDegMin(coord) :
        """Convert decimal degrees to degrees.minutes"""
        if coord < 0 :
            sgn = -1
            coord = -coord
        else :
            sgn = 1
        deg = MapUtils.intTrunc(coord)
        min = abs(coord - deg) * .6
        return sgn * (deg + min)

    @staticmethod
    def DecDegToDegMinStr(coord) :
        """Convert decimal degrees to a nice degrees/minutes string"""
        if coord < 0 :
            sgnstr = '-'
            coord = -coord
        else :
            sgnstr = ''
        deg = MapUtils.intTrunc(coord)
        min = abs(coord - deg) * 60.
        min = MapUtils.TruncateToFrac(min, .01)
        return sgnstr + str(deg) + "^" + str(min) + "'"

    @staticmethod
    def angle_to_bearing(angle) :
        return (450 - angle) % 360

    # Convert an angle (deg) to the appropriate quadrant string, e.g. N 57 E.
    @staticmethod
    def angle_to_quadrant(angle) :
        if angle > 180 :
            angle = angle - 360
        if angle == 0 :
            return "N"
        if angle == -90 :
            return "W"
        if angle == 90 :
            return "E"
        if angle == 180 :
            return "S"
        if angle > -90 and angle < 90 :
            if angle < 0 :
                return "N " + str(-angle) + " W"
            return "N " + str(angle) + " E"
        if angle < 0 :
            return "S " + str(180 + angle) + " W"
        return "S " + str(180 - angle) + " E"

    @staticmethod
    def intTrunc(num) :
        """Truncate to an integer, but no .999999 stuff"""
        return int(num + .00001)

    @staticmethod
    def TruncateToFrac(num, frac) :
        """Truncate to a multiple of the given fraction"""
        t = float(MapUtils.intTrunc(num / frac)) * frac
        if num < 0 :
            t = t - frac
        return t

    @staticmethod
    def ohstring(num, numdigits) :
        """Return a zero-prefixed string of the given number of digits."""
        fmt_arr = [ "", "%01d", "%02d", "%03d", "%04d",
                    "%05d", "%06d", "%07d", "%08d" ]
        if numdigits < len(fmt_arr) :
            return fmt_arr[numdigits] % int(num)
        else :
            s = '%0' + str(numdigits) + 'd'
            return s % int(num)

    @staticmethod
    def ohstring_old(num, numdigits) :
        """Return a zero-prefixed string of the given number of digits."""
        s = str(num)
        mult = pow(10, numdigits-1)
        while (numdigits > 1) :
            if (num < mult) : s = "0" + s
            mult = mult / 10
            numdigits = numdigits-1
        return s

# End of "MapUtils" pseudo-class.

class DownloadTileQueue :
    def __init__(self) :
        self.queue = []    # Will be a list

    def __len__(self) :
        return len(self.queue)

    def push(self, url, path, x, y, x_off, y_off, mapwin) :
        """Push details for a new tile onto the queue if not already there,
           or replace XY info if already there -- the map must have moved.
        """
        for q in self.queue :
            if q[1] == path :  # Are paths the same?
                # Replace XY info
                q[2] = x
                q[3] = y
                q[4] = x_off
                q[5] = y_off
                return
        if path :
            self.queue.insert(0, [url, path, x, y, x_off, y_off, mapwin])

    def pop(self) :
        return self.queue.pop()

    def peek(self) :
        return self.queue[-1]

class MapWindow() :

    """The PyTopo UI: the map window.
This is intended to hold the GTK specific drawing code,
and to be extensible into other widget libraries.
To that end, it needs to implement the following methods
that are expected by the MapCollection classes:
   win_width, win_height = get_size()
   set_bg_color(), set_grid_color(), set_map_color()
   draw_pixbuf(pixbuf, x_off, y_off, x, y, w, h)
   draw_rectangle(fill, x, y, width, height)
   draw_line(x, y, width, height)
"""

    def __init__(self, _controller) :
        """Initialize variables, but don't create the widow yet."""

        # Save a reference to the PyTopo object that created this window.
        # We'll need it to change locations, collections etc.
        self.controller = _controller

        # The current map collection being used:
        self.collection = None

        self.center_lon = 0
        self.center_lat = 0
        self.cur_lon = 0
        self.cur_lat = 0
        self.trackpoints = None

        try :
            self.pin = \
                gtk.gdk.pixbuf_new_from_file("/usr/share/pytopo/pytopo-pin.png")
        except :
            try :
                self.pin = gtk.gdk.pixbuf_new_from_file("pytopo-pin.png")
            except :
                self.pin = None
        self.pin_lon = 0
        self.pin_lat = 0
        self.pin_xoff = -4
        self.pin_yoff = -12

        # Print distances in metric?
        # This should be set externally!
        self.use_metric = False

        # Where to save generated maps. The default is fine for most people.
        # Which is a good thing since there's currently no way to change it.
        self.map_save_dir = os.path.expanduser("~/Topo/")

        # X/gtk graphics variables we need:
        self.drawing_area = 0
        self.xgc = 0

        self.click_last_long = 0
        self.click_last_lat = 0
        self.is_dragging = False

        self.bg_color = gtk.gdk.color_parse("black")
        self.track_color = gtk.gdk.color_parse("magenta")
        self.waypoint_color = gtk.gdk.color_parse("blue")
        # Grid color is only needed when Debug,
        # but this is called before parse_args so we don't know Debug.
        self.grid_color = gtk.gdk.color_parse("green")

        # The timeout for long press events
        self.press_timeout = None

    def show_window(self, init_width, init_height) :
        """Create the initial window."""
        win = gtk.Window()
        win.set_name("PyTopo")
        win.connect("destroy", self.graceful_exit)
        win.set_border_width(5)

        vbox = gtk.VBox(spacing=3)
        win.add(vbox)

        self.drawing_area = gtk.DrawingArea()
        self.drawing_area.set_size_request(init_width, init_height)
        vbox.pack_start(self.drawing_area)

        self.drawing_area.set_events(gtk.gdk.EXPOSURE_MASK |
                                     gtk.gdk.POINTER_MOTION_MASK |
                                     gtk.gdk.POINTER_MOTION_HINT_MASK |
                                     gtk.gdk.BUTTON_PRESS_MASK |
                                     gtk.gdk.BUTTON_RELEASE_MASK )

        self.drawing_area.connect("expose-event", self.expose_event)
        self.drawing_area.connect("button-press-event", self.mousepress)
        self.drawing_area.connect("button-release-event", self.mouserelease)
        self.drawing_area.connect("motion_notify_event", self.drag_event)

        # The default focus in/out handlers on drawing area cause
        # spurious expose events.  Trap the focus events, to block that:
        # XXX can we pass "pass" in to .connect?
        self.drawing_area.connect("focus-in-event", self.nop)
        self.drawing_area.connect("focus-out-event", self.nop)

        # Handle key presses on the drawing area.
        # If seeing spurious expose events, try setting them on win instead,
        # and comment out gtk.CAN_FOCUS.
        self.drawing_area.set_flags(gtk.CAN_FOCUS)
        self.drawing_area.connect("key-press-event", self.key_press_event)

        win.show_all()
        gtk.main()

    #
    # Draw maplets to fill the window, centered at center_lon, center_lat
    #
    def draw_map(self) :
        """Redraw the map, centered at center_lon, center_lat."""
        global Debug

        if self.collection == None :
            print "No collection!"
            return
        if not self.drawing_area :
            # Not initialized yet, not ready to draw a map
            return

        # XXX Collection.draw_map wants center, but we only have lower right.
        if (Debug) :
            print ">>>>>>>>>>>>>>>>"
            print "window draw_map centered at",
            print MapUtils.DecDegToDegMinStr(self.center_lon),
            print MapUtils.DecDegToDegMinStr(self.center_lat)
        self.collection.draw_map(self.center_lon, self.center_lat, self)

        self.draw_trackpoints()

        if not self.is_dragging :
            self.draw_zoom_control()

        # draw pin
        win_width, win_height = self.drawing_area.window.get_size()
        pin_x, pin_y = self.coords2xy(self.pin_lon, self.pin_lat,
                                      win_width, win_height)

        if self.pin :
            self.draw_pixbuf(self.pin, 0,0,pin_x+self.pin_xoff,
                             pin_y+self.pin_yoff,-1,-1)

    def draw_trackpoints(self) :
        # Now draw any trackpoints that are visible:
        if self.trackpoints != None :   # may be trackpoints or waypoints
            win_width, win_height = self.drawing_area.window.get_size()
            if len(self.trackpoints.points) > 0 :
                cur_x = None
                cur_y = None
                self.xgc.line_style = gtk.gdk.LINE_ON_OFF_DASH
                self.xgc.line_width = 3
                self.set_track_color()

                for pt in self.trackpoints.points :
                    x = int((pt[0] - self.center_lon) * self.collection.xscale
                            + win_width/2)
                    y = int((self.center_lat - pt[1]) * self.collection.yscale
                            + win_height/2)

                    if ((x >= 0 and x < win_width and
                         y >= 0 and y < win_height) or
                        (cur_x < win_width and cur_y < win_height)) :
                        if cur_x != None and cur_y != None :
                            self.draw_line(cur_x, cur_y, x, y)
                        cur_x = x
                        cur_y = y
                    else :
                        #print "Skipping", pt[0], pt[1], \
                        #    ": would be", x, ",", y
                        cur_x = None
                        cur_y = None

            if len(self.trackpoints.waypoints) > 0 :
                self.set_waypoint_color()
                self.xgc.line_style = gtk.gdk.LINE_SOLID
                self.xgc.line_width = 2
                for pt in self.trackpoints.waypoints :
                    x = int((pt[0] - self.center_lon) * self.collection.xscale
                            + win_width/2)
                    y = int((self.center_lat - pt[1]) * self.collection.yscale
                            + win_height/2)

                    if x >= 0 and x < win_width and y >= 0 and y < win_height :
                        self.draw_string(x, y, pt[2])
                        self.draw_rectangle(True, x-3, y-3, 6, 6)

    def draw_zoom_control(self) :
        """Draw some zoom controls in case we're running on a tablet
           and have no keyboard to zoom or move around.
           Also draw any other controls we might need.
        """
        win_width, win_height = self.drawing_area.window.get_size()
        self.zoom_btn_size = int(win_width / 25)
        self.zoom_X1 = 8
        self.zoom_in_Y1 = 10
        self.zoom_out_Y1 = self.zoom_in_Y1 + self.zoom_btn_size * 2
        textoffset = self.zoom_btn_size / 5

        self.xgc.line_style = gtk.gdk.LINE_SOLID
        self.set_bg_color()
        self.xgc.line_width = 3
        # Draw the boxes
        self.draw_rectangle(False, self.zoom_X1, self.zoom_in_Y1,
                            self.zoom_btn_size, self.zoom_btn_size)
        self.draw_rectangle(False, self.zoom_X1, self.zoom_out_Y1,
                            self.zoom_btn_size, self.zoom_btn_size)

        midpointx = self.zoom_X1 + self.zoom_btn_size/2
        # Draw the -
        midpointy = self.zoom_out_Y1 + self.zoom_btn_size/2
        self.draw_line(self.zoom_X1 + textoffset, midpointy,
                       self.zoom_X1 + self.zoom_btn_size - textoffset,
                       midpointy)

        # Draw the +
        midpointy = self.zoom_in_Y1 + self.zoom_btn_size/2
        self.draw_line(self.zoom_X1 + textoffset, midpointy,
                       self.zoom_X1 + self.zoom_btn_size - textoffset,
                       midpointy)
        self.draw_line(midpointx, self.zoom_in_Y1 + textoffset,
                       midpointx,
                       self.zoom_in_Y1 + self.zoom_btn_size - textoffset)

    def was_click_in_zoom(self, x, y) :
        """Do the coordinates fall within the zoom in or out buttons?
           Returns 0 for none, 1 for zoom in, -1 for zoom out.
        """
        if x < self.zoom_X1 or x > self.zoom_X1 + self.zoom_btn_size :
            return 0
        if y < self.zoom_in_Y1 or y > self.zoom_out_Y1 + self.zoom_btn_size :
            return 0
        if y < self.zoom_in_Y1 + self.zoom_btn_size :
            return 1
        if y > self.zoom_out_Y1 :
            return -1
        # Must be between buttons
        return 0

    def context_menu(self, event) :
        menu = gtk.Menu()    # Don't need to show menus

        # Create the menu items
        centerpin_item = gtk.MenuItem("Go to pin...")
        pin_item = gtk.MenuItem("Pin this location")
        save_item = gtk.MenuItem("Save pin location...")
        locations_item = gtk.MenuItem("My Locations...")
        tracks_item = gtk.MenuItem("My Tracks...")
        download_item = gtk.MenuItem("Download Area...")
        quit_item = gtk.MenuItem("Quit")

        # Add them to the menu
        menu.append(centerpin_item)
        menu.append(pin_item)
        menu.append(save_item)
        menu.append(locations_item)
        menu.append(tracks_item)
        menu.append(download_item)
        menu.append(quit_item)

        # Attach the callback functions to the activate signal
        centerpin_item.connect("activate", self.set_center_to_pin)
        pin_item.connect("activate", self.set_pin_by_mouse)
        save_item.connect("activate", self.save_location)
        locations_item.connect("activate", self.mylocations)
        tracks_item.connect("activate", self.mytracks)
        download_item.connect("activate", self.download_area)

        # We can attach the Quit menu item to our exit function
        quit_item.connect_object ("activate", self.graceful_exit, "quit")

        # We do need to show menu items
        centerpin_item.show()
        pin_item.show()
        save_item.show()
        locations_item.show()
        tracks_item.show()
        download_item.show()
        quit_item.show()

        if event :
            button = event.button
            t = event.time
        else :
            button = 3
            t = 0
            # There's no documentation on what event.time is: it's
            # "the time of the event in milliseconds" -- but since when?
            # Not since the epoch.
        menu.popup(None, None, None, button, t)

    def mylocations(self, widget) :
        self.controller.location_select(self)

    def set_pin_by_mouse(self, widget) :
        """Set the pin at the current mouse location"""
        self.pin_lon, self.pin_lat = self.cur_lon, self.cur_lat
        self.draw_map()

    def set_center_to_pin(self, widget) :
        """Set the center at the current pin point"""
        self.center_lon, self.center_lat = self.pin_lon, self.pin_lat
        self.draw_map()

    def save_location(self, widget) :
        """Save the pinned location.
        XXX should save zoom level too, if different from collection default.
        """
        dialog = gtk.Dialog("Save location", None, 0,
                            (gtk.STOCK_CANCEL, gtk.RESPONSE_NONE,
                             gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_size_request(200, 150)
        dialog.vbox.set_spacing(10)

        prompt = gtk.Label("Please specify a name:")
        dialog.vbox.pack_start(prompt, expand=False)
        nametext = gtk.Entry()
        dialog.vbox.pack_start(nametext, expand=True)
        comment = gtk.Label("")
        dialog.vbox.pack_start(comment, expand=False)

        dialog.show_all()

        while True :
            response = dialog.run()
            if response == gtk.RESPONSE_OK :
                name = nametext.get_text().strip()
                if not name :
                    comment.set_text("Name can't be empty")
                    continue

                # Add to KnownSites
                self.controller.append_known_site( [name,
                    MapUtils.DecDegToDegMin(self.pin_lon),
                    MapUtils.DecDegToDegMin(self.pin_lat),
                    self.collection.name,
                    self.collection.zoomlevel] )

                dialog.destroy()
                return True
            else :
                dialog.destroy()
                return True

    def mytracks(self, widget) :
        self.controller.TrackSelect(self)
        if self.trackpoints != None :
            self.trackpoints_center()
        self.draw_map()

    def trackpoints_center(self) :
        minlon, minlat, maxlon, maxlat = self.trackpoints.get_bounds()
        self.center_lon = (maxlon + minlon) / 2
        self.center_lat = (maxlat + minlat) / 2

    def cancel_download(self, widget, data=None) :
        self.cancelled = True

    def download_area(self, widget) :
        global Debug
        if not self.collection.zoomlevel :
            print "Can't download an area for this collection"
            return

        # Get default values for area and zoom levels:
        win_width, win_height = self.get_size()
        halfwidth = win_width / self.collection.xscale / 2
        halfheight = win_height / self.collection.yscale / 2
        minlon = self.center_lon - halfwidth
        maxlon = self.center_lon + halfwidth
        minlat = self.center_lat - halfheight
        maxlat = self.center_lat + halfheight
        minzoom = self.collection.zoomlevel
        maxzoom = self.collection.zoomlevel + 4

        # Prompt the user for any adjustments to area and zoom:
        dialog = gtk.Dialog("Download an area", None, 0,
                            (gtk.STOCK_REFRESH, gtk.RESPONSE_APPLY,
                             gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
                             gtk.STOCK_OK, gtk.RESPONSE_OK))
        #dialog.set_size_request(200, 150)
        #dialog.vbox.set_spacing(10)
        frame = gtk.Frame("Current zoom = %d" % self.collection.zoomlevel)
        dialog.vbox.pack_start(frame, True, True, 0)
    
        table = gtk.Table(4, 3, False)
        table.set_border_width(5)
        table.set_row_spacings(5)
        table.set_col_spacings(10)
        frame.add(table)

        label = gtk.Label("Min longitude:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 0, 1, 0, 1,
                     gtk.SHRINK, 0, 0, 0)
        minlon_entry = gtk.Entry()
        table.attach(minlon_entry, 1, 2, 0, 1,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        label = gtk.Label("Max longitude:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 2, 3, 0, 1,
                     gtk.SHRINK, 0, 0, 0)
        maxlon_entry = gtk.Entry()
        table.attach(maxlon_entry, 3, 4, 0, 1,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        label = gtk.Label("Min latitude:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 0, 1, 1, 2,
                     gtk.SHRINK, 0, 0, 0)
        minlat_entry = gtk.Entry()
        table.attach(minlat_entry, 1, 2, 1, 2,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        label = gtk.Label("Max latitude:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 2, 3, 1, 2,
                     gtk.SHRINK, 0, 0, 0)
        maxlat_entry = gtk.Entry()
        table.attach(maxlat_entry, 3, 4, 1, 2,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        label = gtk.Label("Min zoom:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 0, 1, 2, 3,
                     gtk.SHRINK, 0, 0, 0)
        minzoom_entry = gtk.Entry()
        table.attach(minzoom_entry, 1, 2, 2, 3,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        label = gtk.Label("Max zoom:")
        label.set_justify(gtk.JUSTIFY_RIGHT)
        table.attach(label, 2, 3, 2, 3,
                     gtk.SHRINK, 0, 0, 0)
        maxzoom_entry = gtk.Entry()
        table.attach(maxzoom_entry, 3, 4, 2, 3,
                     gtk.EXPAND | gtk.FILL, 0, 0, 0)

        err_label = gtk.Label("")
        dialog.vbox.pack_start(err_label, True, True, 0)

        progress_label = gtk.Label("")
        dialog.vbox.pack_start(progress_label, True, True, 0)

        def flush_events() :
            while gtk.events_pending():
                gtk.main_iteration(False)

        def reset_download_dialog() :
            minlon_entry.set_text(str(minlon))
            maxlon_entry.set_text(str(maxlon))
            minlat_entry.set_text(str(minlat))
            maxlat_entry.set_text(str(maxlat))
            minzoom_entry.set_text(str(minzoom))
            maxzoom_entry.set_text(str(maxzoom))

        reset_download_dialog()

        dialog.show_all()

        self.cancelled = False

        while True :
            response = dialog.run()
            if response == gtk.RESPONSE_CANCEL:
                dialog.destroy()
                return True
            if response == gtk.RESPONSE_APPLY:
                reset_download_dialog()
                continue
            # Else the response must have been OK.
            # So connect the cancel button to cancel_download(),
            # which means first we have to find the cancel button:
            # Starting with PyGTK 2.22 we can use this easier method:
            #cancelBtn = dialog.get_widget_for_response(gtk.RESPONSE_OK)
            # but for now:
            buttons = dialog.get_action_area().get_children()
            for b in buttons :
                if b.get_label() == 'gtk-cancel' :
                    b.connect("clicked", self.cancel_download, str)
                    break

            try :
                minlon = float(minlon_entry.get_text().strip())
                maxlon = float(maxlon_entry.get_text().strip())
                minlat = float(minlat_entry.get_text().strip())
                maxlat = float(maxlat_entry.get_text().strip())
                minzoom = int(minzoom_entry.get_text().strip())
                maxzoom = int(maxzoom_entry.get_text().strip())
                break

            except ValueError :
                err_label.set_text("Sorry, can't parse one of the values")
                continue

        if Debug :
            print "Downloading from %f - %f, %f - %f, zoom %d - %d" \
                % (minlon, maxlon, minlat, maxlat, minzoom, maxzoom)
        for zoom in range(minzoom, maxzoom+1) :
            err_label.set_text("Downloading zoom level %d" % zoom)

            # Show a busy cursor on the dialog:
            busy_cursor = gtk.gdk.Cursor(gtk.gdk.WATCH)
            dialog.window.set_cursor(busy_cursor)
            flush_events()
            gtk.gdk.flush()

            if Debug :
                print "==== Zoom level", zoom

            # Find the start and end tiles
            (minxtile, minytile, x_off, y_off) = \
                self.collection.deg2num(maxlat, minlon, zoom)
            (maxxtile, maxytile, x_off, y_off) = \
                self.collection.deg2num(minlat, maxlon, zoom)
            if Debug :
                print "X tiles from", minxtile, "to", maxxtile
                print "Y tiles from", minytile, "to", maxytile

            pathlist = []
            for ytile in range(minytile, maxytile+1) :
                for xtile in range(minxtile, maxxtile+1) :
                    if Debug :
                        print "Tile", xtile, ytile,
                    filename = os.path.join(self.collection.location,
                                            str(zoom),
                                            str(xtile),
                                            str(ytile)) \
                                            + self.collection.ext
                    if os.access(filename, os.R_OK) :
                        if Debug :
                            print filename, "is already there"
                        continue
                    pathlist.append(filename)
                    if Debug :
                        print "appended as", filename

            numtiles = len(pathlist)
            err_label.set_text("Zoom level %d: %d tiles" % (zoom, numtiles))
            flush_events()
            num_downloaded = 0

            for filename in pathlist :
                if self.cancelled :
                    dialog.destroy()
                    return True

                url = self.collection.url_from_path(filename, zoom)

                # XXX Parallelize this!
                if Debug :
                    print "Downloading", url, "to", filename
                thedir = os.path.dirname(filename)
                if not os.access(thedir, os.W_OK) :
                    os.makedirs(thedir)
                #err_label.set_text("%d %%: %d of %d" % \
                #                   (int(num_downloaded*100 / numtiles),
                #                    num_downloaded, numtiles))
                if Debug :
                    print "%d %%: %d of %d" % \
                        (int(num_downloaded*100 / numtiles),
                         num_downloaded, numtiles)
                progress_label.set_text("%d: %s" % (num_downloaded, url))
                flush_events()
                urllib.urlretrieve(url, filename)
                num_downloaded += 1

                # XXX should show progress more graphically.

        dialog.destroy()
        return True

    #
    # Drawing-related routines:
    #

    def get_size(self) :
        """Return the width and height of the canvas."""
        return self.drawing_area.window.get_size()

    def set_bg_color(self) :
        """Change to the normal background color (usually black)."""
        #self.xgc.set_rgb_fg_color(self.bg_color)
        self.xgc.foreground = self.xgc.background

    def set_grid_color(self) :
        """Change to the color used to show the grid (for debugging)."""
        self.xgc.set_rgb_fg_color(self.grid_color)

    def set_track_color(self) :
        """Change to the color used for tracks. May set line thickness too."""
        self.xgc.set_rgb_fg_color(self.track_color)

    def set_waypoint_color(self) :
        """Change to the color used for tracks. May set line thickness too."""
        self.xgc.set_rgb_fg_color(self.waypoint_color)

    def draw_pixbuf(self, pixbuf, x_off, y_off, x, y, w, h) :
        """Draw the pixbuf at the given position and size,
        starting at the specified offset."""
        self.drawing_area.window.draw_pixbuf(self.xgc, pixbuf, x_off, y_off,
                                              x, y, w, h)
    def draw_rectangle(self, fill, x, y, w, h) :
        """Draw a rectangle."""
        self.drawing_area.window.draw_rectangle(self.xgc, fill, x, y, w, h)

    def draw_line(self, x, y, x2, y2) :
        """Draw a line."""
        self.drawing_area.window.draw_line(self.xgc, x, y, x2, y2)

    def draw_string(self, x, y, str) :
        """Draw a string."""
        import pango
        layout = self.drawing_area.create_pango_layout(str)
        fontdesc = pango.FontDescription("Sans Bold 12")
        layout.set_font_description(fontdesc)
        self.drawing_area.window.draw_layout(self.xgc, x, y, layout)

    # Save the current map as something which could be gimped or printed.
    # XXX THIS IS BROKEN, code assumes start_lon/start_lat but has center_.
    def save_as(self) :

        """Save a static map. Somewhat BROKEN, needs rewriting."""

        file_list = ""
        win_width, win_height = self.get_size()

        # Calculate dAngle in decimal degrees
        dAngle = self.collection.img_width / self.collection.xscale

        # Calculate number of charts based on window size, and round up
        # so the saved map shows at least as much as the window does.
        #win_width, win_height = drawing_area.window.get_size()
        num_lon = int (.8 + float(win_width) / self.collection.img_width)
        num_lat = int (.8 + float(win_height) / self.collection.img_height)

        ny = 0
        curlat = self.center_lat + dAngle*num_lat * .25
        while ny < num_lat :
            curlon = self.center_lon - dAngle*num_lon * .25
            nx = 0
            while nx < num_lon :
                file_list += " " + \
                    self.collection.coords_to_filename(curlon, curlat)
                curlon += dAngle
                nx += 1
            curlat -= dAngle
            ny += 1

        outfile = self.map_save_dir + "topo" + "_" + \
                  str(self.center_lon) + "_" + str(self.center_lat) + ".gif"
        cmdstr = "montage -geometry 262x328 -tile " + \
                 str(nx) + "x" + str(ny) + " " + \
                 file_list + " " + outfile
        #print "Running:", cmdstr
        os.system(cmdstr)

        if (os.access(outfile, os.R_OK)) :
            print "Saved:", outfile

    def expose_event(self, widget, event) :
        """Handle exposes on the canvas."""
        #print "Expose:", event.type, "for object", self
        #print "area:", event.area.x, event.area.y, \
        #    event.area.width, event.area.height

        if self.xgc == 0 :
            self.xgc = self.drawing_area.window.new_gc()
            #self.xgc.set_foreground(white)

        #x, y, w, h = event.area

        self.draw_map()

        return True

    def key_quit(self, *args) :
        """Callback when the user types q."""
        self.graceful_exit()

    def key_press_event(self, widget, event) :
        """Handle any key press."""
        if event.string == "q" :
            self.graceful_exit()
        elif event.string == "+" or event.string == "=" :
            self.collection.zoom(1)
        elif event.string == "-" :
            self.collection.zoom(-1)
        elif event.keyval == gtk.keysyms.Left :
            self.center_lon -= \
                float(self.collection.img_width) / self.collection.xscale
        elif event.keyval == gtk.keysyms.Right :
            self.center_lon += \
                float(self.collection.img_width) / self.collection.xscale
        elif event.keyval == gtk.keysyms.Up :
            self.center_lat += \
                float(self.collection.img_height) / self.collection.yscale
        elif event.keyval == gtk.keysyms.Down :
            self.center_lat -= \
                float(self.collection.img_height) / self.collection.yscale
        elif event.keyval == gtk.keysyms.space :
            self.set_center_to_pin()
        elif event.keyval == gtk.keysyms.l and \
                 event.state == gtk.gdk.CONTROL_MASK :
            pass    # Just fall through to draw_map()
        elif event.string == "m" :
            if PyTopo.selection_window(p, self) :
                self.set_center_to_pin()
                pass
        elif event.string == "s" :
            self.save_as()
            return True
        else :
            #print "Unknown key,", event.keyval
            return False

        self.draw_map()
        return True

    def xy2coords(self, x, y, win_width, win_height) :
        """Convert pixels to longitude/latitude."""
        # collection.x_scale is in pixels per degree.
        return (self.center_lon - \
                    float(win_width/2 - x) / self.collection.xscale,
                self.center_lat + \
                    float(win_height/2 - y) / self.collection.yscale)

    def coords2xy(self, lon, lat, win_width, win_height) :
        """Convert lon/lat to pixels."""
        return (int((lon - self.center_lon) * self.collection.xscale
                    + win_width/2),
                int((self.center_lat - lat) * self.collection.yscale
                    + win_height/2) )

    def drag_event(self, widget, event) :
        """Move the map as the user drags."""

        if self.press_timeout :
            gobject.source_remove(self.press_timeout)
            self.press_timeout = None

        # On a tablet (at least the ExoPC), almost every click registers
        # as a drag. So if a drag starts in the zoom control area,
        # it was probably really meant to be a single click.
        if self.was_click_in_zoom(event.x, event.y) :
            return False

        # The GTK documentation @ 24.2.1
        # http://www.pygtk.org/pygtk2tutorial/sec-EventHandling.html
        # says the first event is a real motion event and subsequent
        # ones are hints; but in practice, nothing but hints are
        # ever sent.
        if event.is_hint:
            x, y, state = event.window.get_pointer()
        else:
            x = event.x
            y = event.y
            state = event.state
        if not state & gtk.gdk.BUTTON1_MASK :
            return False
        if not self.is_dragging :
            self.x_start_drag = x
            self.y_start_drag = y
            self.is_dragging = True
        self.move_to(x, y, widget)
        return True

    def move_to(self, x, y, widget) :
        if widget.drag_check_threshold(self.x_start_drag, self.y_start_drag,
                                       x, y) :
            dx = x - self.x_start_drag
            dy = y - self.y_start_drag
            self.center_lon -= dx / self.collection.xscale
            self.center_lat += dy / self.collection.yscale
            self.draw_map()
            # Reset the drag coordinates now that we're there
            self.x_start_drag = x
            self.y_start_drag = y

    def mousepress(self, widget, event) :
        """Handle mouse button presses"""

        if self.press_timeout :
            gobject.source_remove(self.press_timeout)

        # Was it a right click?
        if event.button == 3 :
            x, y, state = self.drawing_area.window.get_pointer()
            win_width, win_height = self.drawing_area.window.get_size()
            self.cur_lon, self.cur_lat = self.xy2coords(x, y,
                                                        win_width, win_height)
            self.context_menu(event)
            return

        # If it wasn't a double click, set a timeout for LongPress
        if event.type != gtk.gdk._2BUTTON_PRESS :
            self.press_timeout = gobject.timeout_add(1000, self.longpress)
            return False

        # Zoom in if we get a double-click.
        win_width, win_height = self.drawing_area.window.get_size()
        cur_long, cur_lat = self.xy2coords(event.x, event.y,
                                           win_width, win_height)

        self.center_lon = cur_long
        self.center_lat = cur_lat
        self.collection.zoom(1)
        self.draw_map()
        return True

    def longpress(self) :
        if self.press_timeout :
            gobject.source_remove(self.press_timeout)
        x, y, state = self.drawing_area.window.get_pointer()
        win_width, win_height = self.drawing_area.window.get_size()
        self.cur_lon, self.cur_lat = self.xy2coords(x, y, win_width, win_height)
        self.context_menu(None)
        return True

    def mouserelease(self, widget, event) :
        """Handle button releases."""

        if self.press_timeout :
            gobject.source_remove(self.press_timeout)
            self.press_timeout = None
            #return False

        if self.is_dragging:
            self.is_dragging = False
            x, y, state = event.window.get_pointer()
            self.move_to(x, y, widget)
            self.draw_zoom_control()
            return True

        if event.button == 1 :
            global Debug

            zoom =  self.was_click_in_zoom(event.x, event.y)
            if zoom :
                self.collection.zoom(zoom)
                self.draw_map()
                return True

            win_width, win_height = self.drawing_area.window.get_size()
            cur_long, cur_lat = self.xy2coords(event.x, event.y,
                                               win_width, win_height)
            if Debug :
                print "Click:", \
                    MapUtils.DecDegToDegMinStr(cur_long), \
                    MapUtils.DecDegToDegMinStr(cur_lat)

            # Find angle and distance since last click.
            # You would think that we could just use the long and lat
            # differences, but it doesn't work that way, because maps
            # aren't square: away from the equator, longitude isn't
            # a great circle so a degree in longitude doesn't equal
            # a degree in latitude.  (Even at the equator that's true,
            # due to the earth's oblateness, but that is probably small
            # enough to ignore.)
            # So use the current image aspect ratio to convert from angles
            # to pixels, then we'll convert from there to miles/km.
            # XXX This is probably still fairly inaccurate, check!
            if self.click_last_long != 0 and self.click_last_lat != 0 :
                xdiff = (cur_long - self.click_last_long)
                ydiff = (cur_lat - self.click_last_lat)
                dist = math.sqrt(xdiff*xdiff + ydiff*ydiff)
                # dist is now in degrees.
                # Convert to miles using latitude and radius of the earth.
                dist = dist *2. * math.pi / 360.0 * 7926 \
                       * math.cos(cur_lat*math.pi/180)
                if Debug and self.use_metric :
                    print "Distance:", round(dist*1600,2), "meters,", \
                          round(dist*1.6,2), "km"
                elif Debug :
                    print "Distance:", round(dist*5280,2), "feet,", \
                          round(dist,2), "miles"
                angle = int(math.atan2(-ydiff, -xdiff) * 180 / math.pi)
                angle = MapUtils.angle_to_bearing(angle)
                if Debug :
                    print "Bearing:", angle, "=", \
                        MapUtils.angle_to_quadrant(angle)
            self.click_last_long = cur_long
            self.click_last_lat = cur_lat

        return True

    @staticmethod
    def nop(*args) :
        "Do nothing."
        return True

    def graceful_exit(self, extra=None) :
        """Clean up the window and exit.
           The "extra" argument is so it can be calld from GTK callbacks.
        """
        self.controller.save_sites() # Tell PyTopo to save any new sites/tracks

        gtk.main_quit()
        # The python profilers don't work if you call sys.exit here.
#
# End of MapWindow class
#

class PyTopo :

    """A class to hold the mechanics of running the PyTopo program,
    plus some important variables including Collections and KnownSites.
    """

    def __init__(self) :
        self.collections = []
        self.KnownSites = []
        self.KnownTracks = []
        self.init_width = 800
        self.init_height = 600
        self.default_collection = None
        self.needs_saving = False
        self.config_dir = os.path.expanduser("~/.config/pytopo",)
        self.savefilename = os.path.join(self.config_dir, "saved.sites")

    @staticmethod
    def Usage() :
        global VersionString
        print VersionString
        print """
Usage: pytopo [-t trackfile] site_name
       pytopo [-t trackfile] start_lat start_long collection
       pytopo -p : list known sites
       pytopo -h : print this message

Use degrees.decimal_minutes format for coordinates.
Set up site names in ~/.config/pytopo.sites, track logs in ~/Tracks.

Track files may contain track points and/or waypoints;
multiple track files are allowed.

Move around using arrow keys.  q quits.
Click in the map to print the coordinates of the clicked location.
's' will attempt to save the current map as a GIF file in ~/Topo/."""
        sys.exit(1)

    @staticmethod
    def error_out(errstr) :
        """Print an error and exit cleanly."""
        print "==============="
        print errstr
        print "===============\n"
        PyTopo.Usage()

    def append_known_site(self, site) :
        self.KnownSites.append(site)
        self.needs_saving = True

    def save_sites(self) :
        """Write any new KnownSites to file.
           Should only be called from graceful exit.
        """
        if not self.needs_saving :
            return

        try :
            savefile = open(self.savefilename, "w")
        except :
            print "Couldn't open save file", self.savefilename
            return

        for site in self.KnownSites[self.first_saved_site:] :
            # All sites have a string, two floats and another string;
            # some sites may have additional ints after that.
            print >>savefile, '[ "%s", %f, %f, "%s"' % \
                (site[0], site[1], site[2], site[3]),
            if len(site) > 4 :
                print >>savefile, ', ' + ', '.join(map(str, site[4:])),
            print >>savefile, "]"
        savefile.close()

    def print_sites(self) :
        """Print the list of known sites."""
        for site in self.KnownSites :
            print site[0], "(", os.path.basename(site[3]), ")"
            #print site[0], site[1]
        sys.exit(0)

    def find_collection(self, collname) :
        """Find a collection with the given name."""
        global Debug

        #print "Looking for a collection named", collname
        # Make sure collname is a MapCollection we know about:
        collection = None
        for coll in self.collections :
            if collname == coll.name :
                if not coll.exists() :
                    PyTopo.error_out("Can't access location " + coll.location +
                                     " for collection " + collname)
                collection = coll
                if (Debug) :
                    print "Found the collection", collection.name
                return collection
        return collection

    def selection_window(self, mapwin) :
        dialog = gtk.Dialog("Choose a point", None, 0,
                            (gtk.STOCK_CLOSE, gtk.RESPONSE_NONE,
                             gtk.STOCK_OK, gtk.RESPONSE_OK))
        #dialog.connect('destroy', lambda win: gtk.main_quit())
        dialog.set_size_request(400, 300)

        sw = gtk.ScrolledWindow()
        sw.set_shadow_type(gtk.SHADOW_ETCHED_IN)
        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        # List store will hold name, collection-name and site object
        store = gtk.ListStore(str, str, object)

        # Create the list
        for site in self.KnownSites :
            store.append([site[0], site[3], site])

        # http://pygtk.org/pygtk2tutorial/ch-TreeViewWidget.html
        # Make a treeview from the list:
        treeview = gtk.TreeView(store)

        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Location", renderer, text=0)
        #column.pack_start(renderer, True)
        #column.set_resizable(True)
        treeview.append_column(column)
        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Collection", renderer, text=1)
        #column.pack_start(renderer, False)
        treeview.append_column(column)

        #store.set_sort_column_id(0, gtk.SORT_ASCENDING)

        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        sw.add(treeview)

        dialog.vbox.pack_start(sw, expand=True)

        dialog.show_all()

        response = dialog.run()
        if response == gtk.RESPONSE_OK:
            selection = treeview.get_selection()
            model, iter = selection.get_selected()
            if iter :
                #locname = store.get_value(iter, 0)
                #collname = store.get_value(iter, 1)
                site = store.get_value(iter, 2)
                self.use_site(site, mapwin)
                dialog.destroy()
                return True
        else :
            dialog.destroy()
        return False

    def TrackSelect(self, mapwin) :
        dialog = gtk.Dialog("Tracks", None, 0,
                            (gtk.STOCK_CLOSE, gtk.RESPONSE_NONE,
                             gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_size_request(400, 300)

        sw = gtk.ScrolledWindow()
        sw.set_shadow_type(gtk.SHADOW_ETCHED_IN)
        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        # List store will hold Track name and Track file path
        store = gtk.ListStore(str, str)

        # Create the list
        for track in self.KnownTracks :
            store.append([ track[0], track[1] ])

        treeview = gtk.TreeView(store)

        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Track name", renderer, text=0)
        treeview.append_column(column)

        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        sw.add(treeview)

        dialog.vbox.pack_start(sw, expand=True)

        dialog.show_all()

        response = dialog.run()
        if response == gtk.RESPONSE_OK:
            selection = treeview.get_selection()
            model, iter = selection.get_selected()
            if iter :
                trackfile = store.get_value(iter, 1)
                mapwin.trackpoints = TrackPoints()
                mapwin.trackpoints.readTrackFile(trackfile)
                # XXX Might want to handle IOError in case file doesn't exist
                dialog.destroy()
                return True
        else :
            dialog.destroy()
        return False

    def location_select(self, mapwin) :
        dialog = gtk.Dialog("Locations", None, 0,
                            (gtk.STOCK_REMOVE, gtk.RESPONSE_APPLY,
                             gtk.STOCK_CLOSE, gtk.RESPONSE_NONE,
                             gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_size_request(400, 300)

        sw = gtk.ScrolledWindow()
        sw.set_shadow_type(gtk.SHADOW_ETCHED_IN)
        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)

        # List store will hold name, collection-name and site object
        store = gtk.ListStore(str, str, object)

        # Create the list
        for site in self.KnownSites :
            store.append([site[0], site[3], site])

        # Make a treeview from the list:
        treeview = gtk.TreeView(store)

        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Location", renderer, text=0)
        treeview.append_column(column)
        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Collection", renderer, text=1)
        treeview.append_column(column)

        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        sw.add(treeview)

        dialog.vbox.pack_start(sw, expand=True)

        dialog.show_all()

        response = dialog.run()
        while response == gtk.RESPONSE_APPLY :
            selection = treeview.get_selection()
            model, iter = selection.get_selected()
            if iter :
                site = store.get_value(iter, 2)
                self.KnownSites.remove(site)
                store.remove(iter)
            response = dialog.run()

        if response == gtk.RESPONSE_OK:
            selection = treeview.get_selection()
            model, iter = selection.get_selected()
            if iter :
                site = store.get_value(iter, 2)
                self.use_site(site, mapwin)
                dialog.destroy()
                return True
        else :
            dialog.destroy()
        return False

    def use_site(self, site, mapwin) :
        collection = self.find_collection(site[3])
        if not collection :
            return False
        mapwin.collection = collection

        # site[1] and site[2] are the long and lat in deg.minutes
        #print site[0], site[1], site[2]
        mapwin.center_lon = MapUtils.DegMinToDecDeg(site[1])
        mapwin.center_lat = MapUtils.DegMinToDecDeg(site[2])
        mapwin.pin_lon = mapwin.center_lon
        mapwin.pin_lat = mapwin.center_lat
        #print "Center in decimal degrees:", centerLon, centerLat
        if (Debug) :
            print site[0] + ":", \
                MapUtils.DecDegToDegMinStr(mapwin.center_lon), \
                MapUtils.DecDegToDegMinStr(mapwin.center_lat)
        if len(site) > 4 and collection.zoom_to :
            collection.zoom_to(site[4])
        mapwin.draw_map()
        return True

    def parse_args(self, mapwin, args) :
        """Parse runtime arguments."""
        global VersionString
        global Debug

        # Variables we expect from .pytopo:
        do_collection = False

        arg0 = args[0]
        args = args[1:]

        while len(args) > 0 :
            if args[0][0] == '-' and not args[0][1].isdigit() :
                if args[0] == "-v" or args[0] == "--version" :
                    print VersionString
                    sys.exit(0)
                elif args[0] == "-h" or args[0] == "--help" :
                    PyTopo.Usage()

                # Next clause is impossible because of the prev isdigit check:
                #if args[0] == "-15" :
                #    series = 15
                elif args[0] == "-p" :
                    self.print_sites()
                elif args[0] == "-c" :
                    # Specify a collection:
                    if len(args) < 2 :
                        print "-c must specify collection"
                        PyTopo.Usage()
                    mapwin.collection = self.find_collection(args[1])
                    if mapwin.collection == None :
                        PyTopo.error_out("I can't find a map collection called "
                                        + args[1])
                    # Start initially at top left, but subsequent args
                    # may change this:
                    mapwin.center_lon, mapwin.center_lat = \
                        mapwin.collection.get_top_left()
                    if (Debug) :
                        print "Collection", mapwin.collection.name,
                        print "Starting at", \
                            MapUtils.DecDegToDegMinStr(mapwin.center_lon), \
                            ", ", MapUtils.DecDegToDegMinStr(mapwin.center_lat)
                    args = args[1:]

                elif args[0] == "-d" :
                    Debug = True
                elif args[0] == "-t" and len(args) > 1:
                    if mapwin.trackpoints == None :
                        mapwin.trackpoints = TrackPoints()

                    # Is it a known track?
                    for tr in self.KnownTracks :
                        if args[1] == tr[0] :
                            if Debug :
                                print "Reading known track", tr[0], tr[1]
                            args[1] = tr[1]
                            break

                    try :
                        mapwin.trackpoints.readTrackFile(args[1])
                    except IOError :
                        print "Can't read track file", args[1]
                    args = args[1:]
                else :
                    PyTopo.error_out("Unknown flag " + args[0])

                # Done processing this flag
                args = args[1:]
                continue

            # args[0] doesn't start with '-'. Is it a gpx file?
            if len(args[0]) > 4 and args[0][-4:] == '.gpx' :
                if mapwin.trackpoints == None :
                    mapwin.trackpoints = TrackPoints()
                try :
                    mapwin.trackpoints.readTrackFile(args[0])
                except IOError :
                    print "Can't read track file", args[0]
                args = args[1:]
                continue

            # Try to match a known site:
            for site in self.KnownSites :
                if args[0] == site[0] :
                    if not self.use_site(site, mapwin) :
                        continue
                    break

            if mapwin.collection and mapwin.center_lon and mapwin.center_lat :
                args = args[1:]
                continue

            # Doesn't match a known site. Maybe the args are coordinates?
            try :
                if len(args) >= 2 and \
                   len(args[0] > 1 and args[0][1].isdigit) and \
                   len(args[1] > 1 and args[1][1].isdigit) :
                    mapwin.center_lon = MapUtils.DegMinToDecDeg(float(args[0]))
                    mapwin.center_lat = MapUtils.DegMinToDecDeg(float(args[2]))
                    mapwin.collection = self.find_collection(args[3])
                    args = args[2:]
                    continue

            except ValueError, e :
                print "Couldn't parse coordinates"
                PyTopo.Usage()

            # If we get here, we still have an argument but it doesn't
            # match anything we know: flag, collection, site or coordinate.
            print "Remaining args:", args
            PyTopo.Usage()

        # Now we've parsed all the arguments.
        # If we didn't get a collection, use the default, if any:
        if not mapwin.collection and self.default_collection :
            mapwin.collection = self.find_collection(self.default_collection)

        # If we have a collection and a track but no center point,
        # center it on the trackpoints:
        if mapwin.trackpoints != None and mapwin.collection != None \
                and not (mapwin.center_lat and mapwin.center_lon) :
            minlon, minlat, maxlon, maxlat = mapwin.trackpoints.get_bounds()
            mapwin.center_lon = (maxlon + minlon) / 2
            mapwin.center_lat = (maxlat + minlat) / 2
            # XXX Do something useful with min/max
            # XXX in terms of setting the map's zoom level

        # By now, we hope we have the mapwin positioned with a collection
        # and starting coordinates:
        if mapwin.collection and mapwin.center_lon and mapwin.center_lat :
            return

        # Didn't match any known run mode:
        # start in GUI mode choosing a location:
        if not self.selection_window(mapwin) :
            self.Usage()

# Check for a user config file named .pytopo
# in either $HOME/.config/pytopo or $HOME.
#
# Format of the user config file:
# It is a python script, which can include arbitrary python code,
# but the most useful will be KnownSites definitions,
# with coordinates specified in degrees.decimal_minutes,
# like this:
# MapHome = "/cdrom"
# KnownSites = [
#     # Death Valley
#     [ "zabriskie", 116.475, 36.245, "dv_data" ],
#     [ "badwater", 116.445, 36.125, "dv_data" ],
#     # East Mojave
#     [ "zzyzyx", 116.05, 35.08, "emj_data" ]
#     ]

    def exec_config_file(self) :
        """Load the user's .pytopo config file,
        found either in $HOME/.config/pytopo/ or $HOME/pytopo.
        """
        userfile = os.path.join(self.config_dir, "pytopo.sites")
        if not os.access(userfile, os.R_OK) :
            if Debug :
                print "Couldn't open", userfile
            userfile = os.path.expanduser("~/.pytopo")
            if not os.access(userfile, os.R_OK) :
                if Debug :
                    print "Couldn't open", userfile, "either"
                userfile = os.path.join(self.config_dir, "pytopo", ".pytopo")
                if not os.access(userfile, os.R_OK) :
                    if Debug :
                        print "Couldn't open", userfile, "either"
                    userfile = self.create_initial_config()
                    if userfile == None :
                        print "Couldn't create a new pytopo config file"
                        return
                else :
                    print "Suggestion: rename", userfile, \
                          "to ~/.config/pytopo/pytopo.sites"
                    print userfile, "may eventually be deprecated"
        if Debug :
            print "Found", userfile

        # Now we'd better have a userfile

        # Now that we're in a function inside the PyTopo class, we can't
        # just execfile() and set a variable inside that file -- the file
        # can only change it inside a "locals" dictionary.
        # So set up the dictionary:
        locals =  { 'Collections' : [ 3, 4 ],
                    'KnownSites' : [],
                    'init_width' : self.init_width,
                    'init_height' : self.init_height
                  }
        execfile(userfile, globals(), locals)

        # Then extract the changed values back out:
        self.collections = locals['Collections']
        self.KnownSites = locals['KnownSites']
        self.init_width = locals["init_width"]
        self.init_height = locals["init_height"]
        self.default_collection = locals["defaultCollection"]

    def read_saved_sites(self) :
        """Read previously saved (favorite) sites."""
        global Debug
        try :
            savefile = open(self.savefilename, "r")
        except :
            return

        # A line typically looks like this:
        # [ "san-francisco", -121.750000, 37.400000, "openstreetmap" ]
        # or, with an extra optional zoom level,
        # [ "san-francisco", -121.750000, 37.400000, "openstreetmap", 11 ]

        r = re.compile('\["([^"]*)",([-0-9\.]*),([-0-9\.]*),"([^"]*)",?([0-9]+)?\]')
        for line in savefile :
            # First remove all whitespace:
            line = re.sub(r'\s', '', line)
            match = r.search(line)
            if match :
                matches = match.groups()
                # Convert from strings to numbers
                site = [ matches[0], float(matches[1]), float(matches[2]),
                         matches[3] ]
                if len(matches) == 5 and matches[4] != None :
                    site.append(int(matches[4]))
                if Debug :
                    print "Adding", site[0], "to KnownSites"
                self.KnownSites.append( site )

        savefile.close()

    def read_tracks(self) :
        trackdir = os.path.expanduser('~/Tracks')

        if os.path.isdir(trackdir) :
            for file in glob.glob( os.path.join(trackdir, '*.gpx') ):
                head, gpx = os.path.split(file)
                filename = gpx.partition('.')[0]
                self.KnownTracks.append( [filename, file] )

    def create_initial_config(self) :
        """Make an initial configuration file.
           If the user has a ~/.config, make ~/.config/pytopo/pytopo.sites
           else fall back on ~/.pytopo.
        """
        confdir = os.path.expanduser("~/.config/pytopo")
        try :
            if not os.access(confdir, os.W_OK) :
                os.mkdir(confdir)
            userfile = os.path.join(confdir, "pytopo.sites")
            fp = open(userfile, 'w')
        except :
            fp = None
        if not fp :
            userfile = os.path.expanduser("~/.pytopo")
            try :
                fp = open(userfile, 'w')
            except :
                return None

        # Now we have fp open. Write a very basic config to it.
        print >>fp, """# Pytopo site file

# Map collections

Collections = [
    OSMMapCollection( "openstreetmap", "~/Maps/openstreetmap",
                      ".png", 256, 256, 10,
                      "http://a.tile.openstreetmap.org" ),
    ]

defaultCollection = "openstreetmap"

KnownSites = [
    # Some base values to get new users started.
    # Note that these coordinates are a bit northwest of the city centers;
    # they're the coordinates of the map top left, not center.
    [ "san-francisco", -121.75, 37.4, "openstreetmap" ],
    [ "new-york", -73.466, 40.392, "openstreetmap" ],
    [ "london", 0.1, 51.266, "openstreetmap" ],
    [ "sydney", 151.0, -33.5, "openstreetmap" ],
    ]
"""
        fp.close()

        print """Welcome to Pytopo!
Created an initial site file in %s
You can add new sites and collections there; see the instructions at
   http://shallowsky.com/software/topo/
""" % (userfile)
        return userfile

    def main(self, pytopo_args) :
        """main execution routine for pytopo."""
        self.exec_config_file()
        # Remember how many known sites we got from the config file;
        # the rest are read in from saved sites and may need to be re-saved.
        self.first_saved_site = len(self.KnownSites)
        self.read_saved_sites()
        self.read_tracks()
        gc.enable()

        mapwin = MapWindow(self)

        self.parse_args(mapwin, pytopo_args)

        # For cProfile testing, run with a dummy collection (no data needed):
        #mapwin.collection = MapCollection("dummy", "/tmp")

        #print cProfile.__file__
        #cProfile.run('mapwin.show_window()', 'cprof.out')
        # http://docs.python.org/library/profile.html
        # To analyze cprof.out output, do this:
        # import pstats
        # p = pstats.Stats('fooprof')
        # p.sort_stats('time').print_stats(20)

        mapwin.show_window(self.init_width, self.init_height)

#####################################################################
# Some global routines that really should get moved into a class:
#

# A relatively clean way of downloading files in a separate thread.
# http://code.activestate.com/recipes/577129-run-asynchronous-tasks-using-coroutines/
#
def start_job(generator):
    """Start a job (a coroutine that yield generic tasks)."""
    def _task_return(result):
        """Function to be sent to tasks to be used as task_return."""
        def _advance_generator():
            try:
                new_task = generator.send(result)
            except StopIteration:
                return
            new_task(_task_return)
        # make sure the generator is advanced in the main thread
        gobject.idle_add(_advance_generator)
    _task_return(None)
    return generator

import threading
gobject.threads_init()

def threaded_task(function, *args, **kwargs):
    """Run function(*args, **kwargs) inside a thread and return the result."""
    def _task(task_return):
        def _thread():
            result = function(*args, **kwargs)
            gobject.idle_add(task_return, result)
        thread = threading.Thread(target=_thread, args=())
        thread.setDaemon(True)
        thread.start()
    return _task

def download_job(url, localpath, callback):
    global Debug
    def download(url, localpath, callback):
        if Debug :
            print "Downloading", url
        try :
            urllib.urlretrieve(url, localpath)
            return localpath
        except IOError, e :
            return None

    path = yield threaded_task(download, url, localpath, callback)
    if Debug :
        print >>sys.stderr, "[downloaded %s]" % (localpath)
    callback(path)

# Conditional main:
if __name__ == "__main__" :
    p = PyTopo()
    p.main(sys.argv)

